/** * Same-origin proxy for the admin console: /admin/api/ → API /api/admin/ (or /api/v1/ when the * first segment is `v1`). Adds `x-dci-admin-token` from the httpOnly cookie server-side, forwards method / query / * body, streams the upstream body back (raw document bodies included). Mutations require a same-origin * `Sec-Fetch-Site` (or a matching `Origin`) — CSRF guard on top of the SameSite cookie. */ import { NextResponse, type NextRequest } from "next/server"; import { ADMIN_COOKIE, adminApiBase } from "@/lib/admin-api"; export const dynamic = "force-dynamic"; export const maxDuration = 300; const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/; const UPSTREAM_TIMEOUT_MS = 290_000; const PASS_RESPONSE_HEADERS = ["content-type", "content-disposition", "content-length", "x-storage-key", "x-truncated", "x-cache", "etag", "x-ratelimit-limit", "x-ratelimit-remaining"]; function json(status: number, body: Record): NextResponse { const res = NextResponse.json(body, { status }); res.headers.set("cache-control", "no-store"); res.headers.set("x-robots-tag", "noindex, nofollow"); return res; } function sameOrigin(req: NextRequest): boolean { const sfs = req.headers.get("sec-fetch-site"); if (sfs) return sfs === "same-origin"; const origin = req.headers.get("origin"); if (!origin) return false; try { const o = new URL(origin); const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? req.nextUrl.host; return o.host === host; } catch { return false; } } async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }): Promise { const token = req.cookies.get(ADMIN_COOKIE)?.value; if (!token) return json(401, { error: "unauthorized", statusCode: 401 }); const { path } = await ctx.params; if (!Array.isArray(path) || path.length === 0) return json(404, { error: "not found", statusCode: 404 }); for (const seg of path) if (!SEGMENT.test(seg)) return json(400, { error: "invalid path", statusCode: 400 }); const method = req.method.toUpperCase(); const mutation = method !== "GET" && method !== "HEAD"; if (mutation && !sameOrigin(req)) return json(403, { error: "cross-site request blocked", statusCode: 403 }); const [first, ...rest] = path; const upstreamPath = first === "v1" ? `/api/v1/${rest.map(encodeURIComponent).join("/")}` : `/api/admin/${path.map(encodeURIComponent).join("/")}`; const url = `${adminApiBase()}${upstreamPath}${req.nextUrl.search}`; const headers = new Headers(); headers.set("x-dci-admin-token", token); headers.set("accept", req.headers.get("accept") ?? "application/json"); const ct = req.headers.get("content-type"); if (ct) headers.set("content-type", ct); const inm = req.headers.get("if-none-match"); if (inm) headers.set("if-none-match", inm); headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") ?? "127.0.0.1"); headers.set("user-agent", "dci-web-admin-proxy/1.0"); const init: RequestInit & { duplex?: "half" } = { method, headers, redirect: "manual", cache: "no-store", signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }; if (mutation && req.body) { init.body = req.body; init.duplex = "half"; } let upstream: Response; try { upstream = await fetch(url, init); } catch (e) { const timeout = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError"); return json(timeout ? 504 : 502, { error: timeout ? "upstream timeout" : `upstream unreachable: ${e instanceof Error ? e.message : "error"}`, statusCode: timeout ? 504 : 502 }); } const out = new Headers(); for (const h of PASS_RESPONSE_HEADERS) { const v = upstream.headers.get(h); if (v) out.set(h, v); } out.set("cache-control", "no-store"); out.set("x-robots-tag", "noindex, nofollow"); out.set("x-content-type-options", "nosniff"); // never let an HTML body render in the top frame from this origin if ((out.get("content-type") ?? "").includes("text/html")) out.set("content-security-policy", "sandbox; default-src 'none'"); return new Response(method === "HEAD" ? null : upstream.body, { status: upstream.status, headers: out }); } export { proxy as GET, proxy as POST, proxy as PATCH, proxy as PUT, proxy as DELETE, proxy as HEAD };